In this project, you will apply unsupervised learning techniques to identify segments of the population that form the core customer base for a mail-order sales company in Germany. These segments can then be used to direct marketing campaigns towards audiences that will have the highest expected rate of returns. The data that you will use has been provided by our partners at Bertelsmann Arvato Analytics, and represents a real-life data science task.
This notebook will help you complete this task by providing a framework within which you will perform your analysis steps. In each step of the project, you will see some text describing the subtask that you will perform, followed by one or more code cells for you to complete your work. Feel free to add additional code and markdown cells as you go along so that you can explore everything in precise chunks. The code cells provided in the base template will outline only the major tasks, and will usually not be enough to cover all of the minor tasks that comprise it.
It should be noted that while there will be precise guidelines on how you should handle certain tasks in the project, there will also be places where an exact specification is not provided. There will be times in the project where you will need to make and justify your own decisions on how to treat the data. These are places where there may not be only one way to handle the data. In real-life tasks, there may be many valid ways to approach an analysis task. One of the most important things you can do is clearly document your approach so that other scientists can understand the decisions you've made.
At the end of most sections, there will be a Markdown cell labeled Discussion. In these cells, you will report your findings for the completed section, as well as document the decisions that you made in your approach to each subtask. Your project will be evaluated not just on the code used to complete the tasks outlined, but also your communication about your observations and conclusions at each stage.
# import libraries here; add more as necessary
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from helper_functions import scree_plot, get_best_n_comp
from sklearn.preprocessing import OneHotEncoder
# magic word for producing visualizations in notebook
%matplotlib inline
'''
Import note: The classroom currently uses sklearn version 0.19.
If you need to use an imputer, it is available in sklearn.preprocessing.Imputer,
instead of sklearn.impute as in newer versions of sklearn.
'''
There are four files associated with this project (not including this one):
Udacity_AZDIAS_Subset.csv: Demographics data for the general population of Germany; 891211 persons (rows) x 85 features (columns).Udacity_CUSTOMERS_Subset.csv: Demographics data for customers of a mail-order company; 191652 persons (rows) x 85 features (columns).Data_Dictionary.md: Detailed information file about the features in the provided datasets.AZDIAS_Feature_Summary.csv: Summary of feature attributes for demographics data; 85 features (rows) x 4 columnsEach row of the demographics files represents a single person, but also includes information outside of individuals, including information about their household, building, and neighborhood. You will use this information to cluster the general population into groups with similar demographic properties. Then, you will see how the people in the customers dataset fit into those created clusters. The hope here is that certain clusters are over-represented in the customers data, as compared to the general population; those over-represented clusters will be assumed to be part of the core userbase. This information can then be used for further applications, such as targeting for a marketing campaign.
To start off with, load in the demographics data for the general population into a pandas DataFrame, and do the same for the feature attributes summary. Note for all of the .csv data files in this project: they're semicolon (;) delimited, so you'll need an additional argument in your read_csv() call to read in the data properly. Also, considering the size of the main dataset, it may take some time for it to load completely.
Once the dataset is loaded, it's recommended that you take a little bit of time just browsing the general structure of the dataset and feature summary file. You'll be getting deep into the innards of the cleaning in the first major step of the project, so gaining some general familiarity can help you get your bearings.
# Load in the general demographics data.
azdias = pd.read_csv('Udacity_AZDIAS_Subset.csv', sep=';')
# Load in the feature summary file.
feat_info = pd.read_csv('AZDIAS_Feature_Summary.csv', sep=';')
# Check the structure of the data after it's loaded (e.g. print the number of
# rows and columns, print the first few rows).
print('AZDIAS: ', azdias.shape)
print('Feature Summary: ', feat_info.shape)
azdias.head(30)
azdias.describe()
feat_info.head(10)
Tip: Add additional cells to keep everything in reasonably-sized chunks! Keyboard shortcut
esc --> a(press escape to enter command mode, then press the 'A' key) adds a new cell before the active cell, andesc --> badds a new cell after the active cell. If you need to convert an active cell to a markdown cell, useesc --> mand to convert to a code cell, useesc --> y.
The feature summary file contains a summary of properties for each demographics data column. You will use this file to help you make cleaning decisions during this stage of the project. First of all, you should assess the demographics data in terms of missing data. Pay attention to the following points as you perform your analysis, and take notes on what you observe. Make sure that you fill in the Discussion cell with your findings and decisions at the end of each step that has one!
The fourth column of the feature attributes summary (loaded in above as feat_info) documents the codes from the data dictionary that indicate missing or unknown data. While the file encodes this as a list (e.g. [-1,0]), this will get read in as a string object. You'll need to do a little bit of parsing to make use of it to identify and clean the data. Convert data that matches a 'missing' or 'unknown' value code into a numpy NaN value. You might want to see how much data takes on a 'missing' or 'unknown' code, and how much data is naturally missing, as a point of interest.
As one more reminder, you are encouraged to add additional cells to break up your analysis into manageable chunks.
# Before Preprocessing
azdias_null = azdias.isnull().sum()[azdias.isnull().sum() != 0]
df_azdias_null = pd.DataFrame({'Count': azdias_null, '%': azdias_null/azdias.shape[0]*100})
df_azdias_null.sort_values(by='Count', ascending=False, inplace=True)
df_azdias_null
# Identify missing or unknown data values and convert them to NaNs.
np_array = feat_info['missing_or_unknown'].str.replace('[','').str.replace(']','').str.split(',').values
df_mou_list = pd.DataFrame({'missing_or_unknown': np_array}, index=feat_info['attribute'].values)
def convert_missing_vals():
for index, row in df_mou_list.iterrows():
if not ('' in row[0] or 'X' in row[0] or 'XX' in row[0]):
row[0] = [int(row[0][i]) for i in range(0, len(row[0]))]
# Preprocess azdias demographics data
for column in azdias.columns:
azdias[column] = azdias[column].replace(df_mou_list.loc[column][0], np.nan)
convert_missing_vals()
# Check that column values that matches a 'missing' or 'unknown' value code are converted to NaN
azdias.head(10)
How much missing data is present in each column? There are a few columns that are outliers in terms of the proportion of values that are missing. You will want to use matplotlib's hist() function to visualize the distribution of missing value counts to find these columns. Identify and document these columns. While some of these columns might have justifications for keeping or re-encoding the data, for this project you should just remove them from the dataframe. (Feel free to make remarks about these outlier columns in the discussion, however!)
For the remaining features, are there any patterns in which columns have, or share, missing data?
# Perform an assessment of how much missing data there is in each column of the
# dataset.
# Missing data count and % after preprocessing
azdias_null = azdias.isnull().sum()[azdias.isnull().sum() != 0]
df_azdias_null = pd.DataFrame({'Count': azdias_null, '%': azdias_null/azdias.shape[0]*100})
df_azdias_null.sort_values(by='Count', ascending=False, inplace=True)
df_azdias_null
# Investigate patterns in the amount of missing data in each column.
df_azdias_null.describe()
print(df_azdias_null.shape)
plt.style.use('ggplot')
plt.xlabel('% of missing data')
plt.hist(df_azdias_null['%'])
df_azdias_null['%'] >= df_azdias_null['%'].std()
plt.figure(figsize=(10, 20))
sns.barplot(x=df_azdias_null['%'], y=df_azdias_null.index, data=df_azdias_null)
outliers = df_azdias_null['%'][df_azdias_null['%'] > df_azdias_null['%'].std()]
outliers
# Remove the outlier columns from the dataset. (You'll perform other data
# engineering tasks such as re-encoding and imputation later.)
outliers = df_azdias_null['%'][df_azdias_null['%'] > 18]
azdias.drop(outliers.index, axis=1, inplace=True)
azdias.shape
(reporting your observations regarding the amount of missing data in each column. Are there any patterns in missing values? Which columns were removed from the dataset?)
Before preprocessing demographics data for the general population of Germany (azdias), 'KK_KUNDENTYP' seemed to be the only outlier which had 584612 counts of missing data (65.6% of the azdias dataset).
However, after preprocessing the dataset, the following columns turned out to be above the standard deviation:
However, based on the bar plot, the % of missing data in 'KKK' and 'REGIOTYP' columns didn't seem to be outliers. So, I've decided to drop only the top 6 columns from the columns listed above.
The number of columns has now changed from 85 to 79.
Now, you'll perform a similar assessment for the rows of the dataset. How much data is missing in each row? As with the columns, you should see some groups of points that have a very different numbers of missing values. Divide the data into two subsets: one for data points that are above some threshold for missing values, and a second subset for points below that threshold.
In order to know what to do with the outlier rows, we should see if the distribution of data values on columns that are not missing data (or are missing very little data) are similar or different between the two groups. Select at least five of these columns and compare the distribution of values.
countplot() function to create a bar chart of code frequencies and matplotlib's subplot() function to put bar charts for the two subplots side by side.Depending on what you observe in your comparison, this will have implications on how you approach your conclusions later in the analysis. If the distributions of non-missing features look similar between the data with many missing values and the data with few or no missing values, then we could argue that simply dropping those points from the analysis won't present a major issue. On the other hand, if the data with many missing values looks very different from the data with few or no missing values, then we should make a note on those data as special. We'll revisit these data later on. Either way, you should continue your analysis for now using just the subset of the data with few or no missing values.
# How much data is missing in each row of the dataset?
azdias_null_row = azdias.isnull().sum(axis=1)
# azdias_null_row.describe()
df_azdias_null_row = pd.DataFrame({'Count': azdias_null_row, '%': round(azdias_null_row/azdias.shape[1]*100, 2)})
df_azdias_null_row.sort_values(by='Count', ascending=False, inplace=True)
df_azdias_null_row
plt.figure(figsize=(10, 10))
plt.style.use('ggplot')
plt.xlabel('% of missing data')
plt.hist(df_azdias_null_row['%'])
# Write code to divide the data into two subsets based on the number of missing
# values in each row.
row_above_threshold = df_azdias_null_row[df_azdias_null_row['Count'] >= 32]
row_below_threshold = df_azdias_null_row[df_azdias_null_row['Count'] < 32]
print(row_above_threshold) # many missing values
print(row_below_threshold) # few missing values
# Compare the distribution of values for at least five columns where there are
# no or few missing values, between the two subsets.
df_azdias_null.sort_values(by='Count', inplace=True)
top_k = df_azdias_null[df_azdias_null['%'] < 18][:5]
top_k.index
print(top_k)
def compare_plots(column):
fig = plt.figure(figsize=(15, 10))
ax1 = fig.add_subplot(2, 2, 1)
ax2 = fig.add_subplot(2, 2, 2)
ax1.set_title('# of missing values in each row above 32')
ax2.set_title('# of missing values in each row below 32')
sns.countplot(azdias.loc[row_above_threshold.index, column], ax=ax1)
sns.countplot(azdias.loc[row_below_threshold.index, column], ax=ax2)
for i in range(top_k.shape[0]):
compare_plots(top_k.index[i])
(Are the data with lots of missing values are qualitatively different from data with few or no missing values?)
An analysis on the missing values in each row in the following columns has been conducted:
First, the dataset was divided into two subsets based on the number of missing values in each row, 1) one that is equal or above 32, and 2) the other below 32.
Then, the distribution of data values on the 5 columns listed above that are not missing data (or are missing very little data) was visualised.
The distribution patterns on all of the columns between the data with many missing values and the data with few or no missing values are qualitatively different, which suggest that they should not be dropped.
Checking for missing data isn't the only way in which you can prepare a dataset for analysis. Since the unsupervised learning techniques to be used will only work on data that is encoded numerically, you need to make a few encoding changes or additional assumptions to be able to make progress. In addition, while almost all of the values in the dataset are encoded using numbers, not all of them represent numeric values. Check the third column of the feature summary (feat_info) for a summary of types of measurement.
In the first two parts of this sub-step, you will perform an investigation of the categorical and mixed-type features and make a decision on each of them, whether you will keep, drop, or re-encode each. Then, in the last part, you will create a new data frame with only the selected and engineered columns.
Data wrangling is often the trickiest part of the data analysis process, and there's a lot of it to be done here. But stick with it: once you're done with this step, you'll be ready to get to the machine learning parts of the project!
# Create a dataset with the subset with few or no missing values.
azdias = azdias[azdias.isnull().sum(axis=1) < 32]
azdias.reset_index(inplace=True, drop=True)
azdias.isnull().sum(axis=1).sum()
# How many features are there of each data type?
feat_info.head()
# feat_info[feat_info['type'] == 'interval']
feat_info['type'].value_counts()
For categorical data, you would ordinarily need to encode the levels as dummy variables. Depending on the number of categories, perform one of the following:
# drop features that were dropped in the demographics data
for i in range(len(outliers.index)):
feat_info.drop(feat_info.index[feat_info['attribute'] == outliers.index[i]].tolist(), inplace=True)
feat_info
cat_variables = feat_info[feat_info['type'] == 'categorical']
cat_variables
# Assess categorical variables: which are binary, which are multi-level, and
# which one needs to be re-encoded?
cat_binary = []
cat_multi = []
# print(cat_variables.shape)
for cat in cat_variables['attribute']:
if azdias[cat].nunique() > 2:
cat_multi.append(cat)
else:
cat_binary.append(cat)
print('binary categorical variables: ', cat_binary)
print('multi-level categorical variables: ', cat_multi)
# Re-encode categorical variable(s) to be kept in the analysis.
'''
OST_WEST_KZ is the one that takes on non-numeric values, 'O' and 'W'.
'''
azdias['OST_WEST_KZ'].replace(['O', 'W'], [0, 1], inplace=True)
azdias['OST_WEST_KZ'].head()
'''
For multi-level categoricals, encode the values
using multiple dummy variables (e.g. via OneHotEncoder).
'''
# Didn't work..
# df_cat_multi = pd.DataFrame(cat_multi)
# enc = OneHotEncoder(categories=df_cat_multi, handle_unknown='ignore', sparse=False)
# enc.fit_transform(df_cat_multi)
azdias = pd.get_dummies(azdias, columns=cat_multi)
azdias
(reporting your findings and decisions regarding categorical features. Which ones did you keep, which did you drop, and what engineering steps did you perform?)
Before determining which categorical features to encode, I had to drop features that were dropped in the demographics data from feat_info dataset.
After that, I divided the categorical variables into two lists, binary and multi-level ones:
As mentioned in the instruction of this section, since there is one binary variable that takes on non-numeric values (OST_WEST_KZ), I re-encoded the 'O's and 'W's into 0s and 1s respectively.
Lastly, I performed one-hot encoding on the multi-level categorical variables as dropping them may significantly affect the learning process.
There are a handful of features that are marked as "mixed" in the feature summary that require special treatment in order to be included in the analysis. There are two in particular that deserve attention; the handling of the rest are up to your own choices:
Be sure to check Data_Dictionary.md for the details needed to finish these tasks.
cat_mixed = feat_info[feat_info['type'] == 'mixed']
cat_mixed
# Investigate "PRAEGENDE_JUGENDJAHRE" and engineer two new variables.
azdias[['PRAEGENDE_JUGENDJAHRE']].head(50)
# create two new variables to capture the other two dimensions: an interval-type variable for decade, and a binary variable for movement.
## 0: 40s, 1: 50s, 2: 60s, 3: 70s, 4: 80s, 5: 90s
gen_dict = {0: [1, 2], 1: [3, 4], 2: [5, 6, 7], 3: [8, 9], 4: [10, 11, 12, 13], 5: [14, 15]}
## Avangarde: 0, Mainstream:1
mov_dict = {0: [2, 4, 6, 7, 9, 11, 13, 15], 1: [1, 3, 5, 8, 10, 12, 14]}
azdias['PRAEGENDE_JUGENDJAHRE_DECADE'] = azdias['PRAEGENDE_JUGENDJAHRE']
azdias['PRAEGENDE_JUGENDJAHRE_MOVEMENT'] = azdias['PRAEGENDE_JUGENDJAHRE']
def create_gen_mov_dim(df, dict_name, column):
for key, val in dict_name.items():
for i in range(len(val)):
if dict_name[key][i] in df[column]:
df[column].replace(dict_name[key][i], key, inplace=True)
create_gen_mov_dim(azdias, gen_dict, 'PRAEGENDE_JUGENDJAHRE_DECADE')
azdias[['PRAEGENDE_JUGENDJAHRE_DECADE']].head(10)
create_gen_mov_dim(azdias, mov_dict, 'PRAEGENDE_JUGENDJAHRE_MOVEMENT')
azdias[['PRAEGENDE_JUGENDJAHRE_MOVEMENT']].head(20)
# Investigate "CAMEO_INTL_2015" and engineer two new variables.
azdias[['CAMEO_INTL_2015']]
#Break up the two-digit codes by their 'tens'-place and 'ones'-place digits into two new ordinal variables
azdias['CAMEO_INTL_2015_WEALTH'] = azdias['CAMEO_INTL_2015']
azdias['CAMEO_INTL_2015_LIFESTAGE'] = azdias['CAMEO_INTL_2015']
replace_list = {}
def get_digit_place(df, column, index):
for i in range(0, len(df[column])):
if not pd.isnull(df[column][i]):
val = int(str(df[column][i])[index])
replace_list[df[column][i]] = val
df[column].replace(replace_list, inplace=True)
get_digit_place(azdias, 'CAMEO_INTL_2015_WEALTH', 0)
azdias[['CAMEO_INTL_2015_WEALTH']]
get_digit_place(azdias, 'CAMEO_INTL_2015_LIFESTAGE', 1)
azdias[['CAMEO_INTL_2015_LIFESTAGE']]
azdias.drop('PRAEGENDE_JUGENDJAHRE', axis=1, inplace=True)
azdias.drop('CAMEO_INTL_2015', axis=1, inplace=True)
(reporting your findings and decisions regarding mixed-value features. Which ones did you keep, which did you drop, and what engineering steps did you perform?)
As suggested in the instruction, I re-engineered following columns:
For the first column, I created two new columns, one based on generation decades with interval categorical variables and the other based on movements with binary categorical variables out of the first column.
Here is the dictionary mapping that I created to re-configure category types in the first column:
For the second column, I re-configured column values into ordial type by breaking up the two-digit codes by their 'tens'-place and 'ones'-place digits and replacing them with the column values based on wealth(='tens'-place) and life stage(='ones'-place).
After reconfiguration, I dropped PRAEGENDE_JUGENDJAHRE and CAMEO_INTL_2015 so that they won't interfere with the analysis later in the project.
In order to finish this step up, you need to make sure that your data frame now only has the columns that you want to keep. To summarize, the dataframe should consist of the following:
Make sure that for any new columns that you have engineered, that you've excluded the original columns from the final dataset. Otherwise, their values will interfere with the analysis later on the project. For example, you should not keep "PRAEGENDE_JUGENDJAHRE", since its values won't be useful for the algorithm: only the values derived from it in the engineered features you created should be retained. As a reminder, your data should only be from the subset with few or no missing values.
Even though you've finished cleaning up the general population demographics data, it's important to look ahead to the future and realize that you'll need to perform the same cleaning steps on the customer demographics data. In this substep, complete the function below to execute the main feature selection, encoding, and re-engineering steps you performed above. Then, when it comes to looking at the customer data in Step 3, you can just run this function on that DataFrame to get the trimmed dataset in a single step.
customers = pd.read_csv('Udacity_CUSTOMERS_Subset.csv', sep=';')
def clean_data(df):
"""
Perform feature trimming, re-encoding, and engineering for demographics
data
INPUT: Demographics DataFrame
OUTPUT: Trimmed and cleaned demographics DataFrame
"""
outlier_columns = ['TITEL_KZ', 'AGER_TYP', 'ALTER_HH', 'GEBURTSJAHR', 'KBA05_BAUMAX', 'KK_KUNDENTYP']
cat_binary = []
cat_multi = []
cat_variables = feat_info[feat_info['type'] == 'categorical']
## 0: 40s, 1: 50s, 2: 60s, 3: 70s, 4: 80s, 5: 90s
gen_dict = {0: [1, 2], 1: [3, 4], 2: [5, 6, 7], 3: [8, 9], 4: [10, 11, 12, 13], 5: [14, 15]}
## Avangarde: 0, Mainstream:1
mov_dict = {0: [2, 4, 6, 7, 9, 11, 13, 15], 1: [1, 3, 5, 8, 10, 12, 14]}
## for storing the mappings of tens-places and ones-places
replace_list = {}
# convert missing value codes into NaNs, ...
for index, row in df_mou_list.iterrows():
if not ('' in row[0] or 'X' in row[0] or 'XX' in row[0]):
row[0] = [int(row[0][i]) for i in range(0, len(row[0]))]
# Preprocess demographics data
for column in df.columns:
df[column] = df[column].replace(df_mou_list.loc[column][0], np.nan)
# remove selected columns and rows, ...
df.drop(outlier_columns, axis=1, inplace=True)
print('before removing NaNs', df.isnull().sum(axis=1).sum())
# create a dataset with the subset with few or no missing values.
df = df[df.isnull().sum(axis=1) < 32]
df.reset_index(inplace=True, drop=True)
print('after', df.isnull().sum(axis=1).sum())
# select, re-encode, and engineer column values.
for cat in cat_variables['attribute']:
if df[cat].nunique() > 2:
cat_multi.append(cat)
else:
cat_binary.append(cat)
df['OST_WEST_KZ'].replace(['O', 'W'], [0, 1], inplace=True)
df = pd.get_dummies(df, columns=cat_multi)
df['PRAEGENDE_JUGENDJAHRE_DECADE'] = df['PRAEGENDE_JUGENDJAHRE']
df['PRAEGENDE_JUGENDJAHRE_MOVEMENT'] = df['PRAEGENDE_JUGENDJAHRE']
create_gen_mov_dim(df, gen_dict, 'PRAEGENDE_JUGENDJAHRE_DECADE')
create_gen_mov_dim(df, mov_dict, 'PRAEGENDE_JUGENDJAHRE_MOVEMENT')
df['CAMEO_INTL_2015_WEALTH'] = df['CAMEO_INTL_2015']
df['CAMEO_INTL_2015_LIFESTAGE'] = df['CAMEO_INTL_2015']
get_digit_place(df, 'CAMEO_INTL_2015_WEALTH', 0)
get_digit_place(df, 'CAMEO_INTL_2015_LIFESTAGE', 1)
df.drop('PRAEGENDE_JUGENDJAHRE', axis=1, inplace=True)
df.drop('CAMEO_INTL_2015', axis=1, inplace=True)
# Return the cleaned dataframe.
return df
df = clean_data(customers)
df.shape
Before we apply dimensionality reduction techniques to the data, we need to perform feature scaling so that the principal component vectors are not influenced by the natural differences in scale for features. Starting from this part of the project, you'll want to keep an eye on the API reference page for sklearn to help you navigate to all of the classes and functions that you'll need. In this substep, you'll need to check the following:
.fit_transform() method to both fit a procedure to the data as well as apply the transformation to the data at the same time. Don't forget to keep the fit sklearn objects handy, since you'll be applying them to the customer demographics data towards the end of the project.# If you've not yet cleaned the dataset of all NaN values, then investigate and
# do that now.
azdias.isnull().sum().sum()
azdias_copy = azdias.copy()
azdias_copy.isnull().sum(axis=1).sum()
azdias_copy.dropna(inplace=True)
azdias_copy.reset_index(inplace=True, drop=True)
print('% of # of rows with NaNs {}%'.format(round(azdias.shape[0] / 891221 *100), 2))
print('% of # of rows with NaNs removed {}%'.format(round(azdias_copy.shape[0] / 891221 *100), 2))
azdias.dropna(inplace=True)
azdias.reset_index(inplace=True, drop=True)
azdias.isnull().sum().sum()
# Apply feature scaling to the general population demographics data
scaler = StandardScaler()
X = scaler.fit_transform(azdias)
Upon determing whether to drop rows with NaN values or not for feature scaling, I compared the number of rows between with NaN values and without. While the former retains 90% of the original dataset, I chose to drop NaN values since I expect to have a decent result even with 70% of the original dataset with an increase in processing time.
So, I made sure there's no missing values left in the dataset. Then, I used StandardScaler to scale each feature to mean 0 and standard deviation 1.
On your scaled data, you are now ready to apply dimensionality reduction techniques.
plot() function. Based on what you find, select a value for the number of transformed features you'll retain for the clustering part of the project.# Apply PCA to the data.
def do_pca(n_components, data):
'''
Transforms data using PCA to create n_components, and provides back the results of the
transformation.
INPUT: n_components - int - the number of principal components to create
data - the data you would like to transform
OUTPUT: pca - the pca object created after fitting the data
X_pca - the transformed X matrix with new number of components
'''
X = StandardScaler().fit_transform(data)
pca = PCA(n_components)
X_pca = pca.fit_transform(X)
return pca, X_pca
# Investigate the variance accounted for by each principal component.
pca = PCA()
X_pca = pca.fit_transform(X)
scree_plot(pca)
# azdias.shape[1]
for comp in range(40, azdias.shape[1]):
print('processing: ', comp)
pca, X_pca = do_pca(comp, azdias)
comp_check = get_best_n_comp(azdias, pca)
if comp_check['Explained Variance'].sum() > 0.60:
break
print(comp_check)
num_comps = comp_check.shape[0]
print('# of components to get more than 60% of variance explained: ', num_comps)
# Re-apply PCA to the data while selecting for number of components to retain.
pca, X_pca = do_pca(46, azdias)
scree_plot(pca)
(reporting your findings and decisions regarding dimensionality reduction. How many principal components / transformed features are you retaining for the next step of the analysis?)
I decided to keep 46 principal components since it's where it reaches 60% of total variability explained.
Initially, I looked into where the line graph starts to level off to determine the number of principal components to retain based on the suggestions in this link.
I conducted an analysis first with all principal components (194) and found out that the cumulative proportion of variance explained seemd to level off around 6, 40 and 125 components. Also, for the first component, 8.64% of total variability is explained by this component. And 5.92% for the second component.
As having 6 wouldn't produce high enough total variability explained, I decided to find a point where the explained variability reaches around 80% with less number of principal components required.
So, I tried with 83 components next. But this took so much time to run PCA so I chose 46 components to retain which has around 60% variability explained.
One last thing to note is that adding more PC doesn't seem to increase the % of variance explained after the 6th component.
Now that we have our transformed principal components, it's a nice idea to check out the weight of each variable on the first few components to see if they can be interpreted in some fashion.
As a reminder, each principal component is a unit vector that points in the direction of highest variance (after accounting for the variance captured by earlier principal components). The further a weight is from zero, the more the principal component is in the direction of the corresponding feature. If two features have large weights of the same sign (both positive or both negative), then increases in one tend expect to be associated with increases in the other. To contrast, features with different signs can be expected to show a negative correlation: increases in one variable should result in a decrease in the other.
def pca_results(full_dataset, pca, n_component):
'''
Return the heads and tails (10 each) of the PCA results and visualise them
'''
# PCA components
components = pd.DataFrame(np.round(pca.components_, 4), columns = full_dataset.keys()).iloc[n_component - 1]
components.sort_values(ascending=False, inplace=True)
components = pd.concat([components.head(10), components.tail(10)])
# Create a bar plot visualization
fig, ax = plt.subplots(figsize = (14,8))
# Plot the feature weights as a function of the components
components.plot(ax = ax, kind = 'bar')
ax.set_ylabel("Feature Weights")
# Map weights for the first principal component to corresponding feature names
# and then print the linked values, sorted by weight.
# HINT: Try defining a function here or in a new cell that you can reuse in the
# other cells.
pca_results(azdias, pca, 1)
# Map weights for the second principal component to corresponding feature names
# and then print the linked values, sorted by weight.
pca_results(azdias, pca, 2)
# Map weights for the third principal component to corresponding feature names
# and then print the linked values, sorted by weight.
pca_results(azdias, pca, 3)
(reporting your observations from detailed investigation of the first few principal components generated. Can we interpret positive and negative values from them in a meaningful way?)
The top 5 positive values in this component are related to social staus and financial affluence:
The negative values that are correlated to the above positive values are related to movement patterns in regions, family size, financial interest, and # of buildings:
Based on this result, I think it can be said that wealth positively impacts the principal component while having low financial interest, movement patterns and number of buildings in the microcell. Number of cars and family size can have either positive or negative impact depending on its number and size respectively.
The top 5 positive values in this component are related to estimated age, financial preparedness, consumption tendency, and personality:
The negative values that are correlated to the above positive values are related to age, money spending habit, and personality:
Based on this result, it seems that there's a correlation between age and one's personality that may influece one's spending habit.
The top 5 positive values in this component are related to personality and the financial type of investors:
The negative values that are correlated to the above positive values are related to gender and personality:
Based on this result, it seems that socially-minded and cultural-minded people tend to be an investor type. And age has some impact on personalities.
You've assessed and cleaned the demographics data, then scaled and transformed them. Now, it's time to see how the data clusters in the principal components space. In this substep, you will apply k-means clustering to the dataset and use the average within-cluster distances from each point to their assigned cluster's centroid to decide on a number of clusters to keep.
.score() method might be useful here, but note that in sklearn, scores tend to be defined so that larger is better. Try applying it to a small, toy dataset, or use an internet search to help your understanding.cluster_counts = np.arange(1, 26)
score_list = []
# Over a number of different cluster counts...
for k in cluster_counts:
# run k-means clustering on the data and...
kmeans = KMeans(n_clusters=k, random_state=0).fit(X_pca)
# compute the average within-cluster distances.
score_list.append(np.abs(kmeans.score(X_pca)))
# Investigate the change in within-cluster distance across number of clusters.
# HINT: Use matplotlib's plot function to visualize this relationship.
plt.plot(cluster_counts, score_list, marker='o')
plt.xlabel('Num of clusers')
plt.ylabel('Score')
cluster_counts = np.arange(1, 16)
score_list = []
# Over a number of different cluster counts...
for k in cluster_counts:
# run k-means clustering on the data and...
kmeans = KMeans(n_clusters=k, random_state=0).fit(X_pca)
# compute the average within-cluster distances.
score_list.append(np.abs(kmeans.score(X_pca)))
# Investigate the change in within-cluster distance across number of clusters.
# HINT: Use matplotlib's plot function to visualize this relationship.
plt.plot(cluster_counts, score_list, marker='o')
plt.xlabel('Num of clusers')
plt.ylabel('Score')
# Re-fit the k-means model with the selected number of clusters and obtain
# cluster predictions for the general population demographics data.
def kmeans_clustering(pca, n_cluster):
kmeans = KMeans(n_clusters=n_cluster, random_state=0).fit(pca)
labels = kmeans.predict(pca)
centers = kmeans.cluster_centers_
return kmeans, labels, centers
kmeans, labels, centers = kmeans_clustering(X_pca, 15)
(reporting your findings and decisions regarding clustering. Into how many clusters have you decided to segment the population?)
To find a elbow point, an indicator for the optimal k, I plotted 2 the score v.s. num of clusters graph, one plotting 25 clusters and one with 15.
While neither of the graphs showed a clear elbow point, I chose 15 for the number of clusters as the rate of decrease in the score seemd to plateau around that point.
Now that you have clusters and cluster centers for the general population, it's time to see how the customer data maps on to those clusters. Take care to not confuse this for re-fitting all of the models to the customer data. Instead, you're going to use the fits from the general population to clean, transform, and cluster the customer data. In the last step of the project, you will interpret how the general population fits apply to the customer data.
;) delimited.clean_data() function you created earlier. (You can assume that the customer demographics data has similar meaning behind missing data patterns as the general demographics data.).fit() or .fit_transform() method to re-fit the old objects, nor should you be creating new sklearn objects! Carry the data through the feature scaling, PCA, and clustering steps, obtaining cluster assignments for all of the data in the customer demographics data.# Load in the customer demographics data.
customers = pd.read_csv('Udacity_CUSTOMERS_Subset.csv', sep=';')
customers.head(10)
# Apply preprocessing, feature transformation, and clustering from the general
# demographics onto the customer data, obtaining cluster predictions for the
# customer demographics data.
cleaned_customer = clean_data(customers)
cleaned_customer.head(10)
azdias.shape
cleaned_customer.shape # 1 column is missing...!
# Investigate which column is missing
list(set(azdias.columns) - set(cleaned_customer)) # 'GEBAEUDETYP_5.0' is missing
# add the missing column to the customer dataset to match the number of columns
cleaned_customer['GEBAEUDETYP_5.0'] = 0
cleaned_customer['GEBAEUDETYP_5.0']
# adding constant values to pandas dataframe => https://stackoverflow.com/questions/24039023/add-column-with-constant-value-to-pandas-dataframe
cleaned_customer.shape # num of columns matches!
cleaned_customer.isnull().sum().sum()
c_copy = cleaned_customer.copy()
c_copy.isnull().sum(axis=1).sum()
c_copy.dropna(inplace=True)
c_copy.reset_index(inplace=True, drop=True)
print('% of # of rows with NaNs {}%'.format(round(cleaned_customer.shape[0] / 891221 *100), 2))
print('% of # of rows with NaNs removed {}%'.format(round(c_copy.shape[0] / 891221 *100), 2))
# drop NaNs from the customer dataset
cleaned_customer.dropna(inplace=True)
cleaned_customer.reset_index(inplace=True, drop=True)
cleaned_customer.isnull().sum().sum()
cleaned_customer.shape
# scale, PCA, clustering steps, predict
X_customer = scaler.fit_transform(cleaned_customer)
pca_customer = pca.transform(X_customer)
pred_customer = kmeans.predict(pca_customer)
pred_customer
At this point, you have clustered data based on demographics of the general population of Germany, and seen how the customer data for a mail-order sales company maps onto those demographic clusters. In this final substep, you will compare the two cluster distributions to see where the strongest customer base for the company is.
Consider the proportion of persons in each cluster for the general population, and the proportions for the customers. If we think the company's customer base to be universal, then the cluster assignment proportions should be fairly similar between the two. If there are only particular segments of the population that are interested in the company's products, then we should see a mismatch from one to the other. If there is a higher proportion of persons in a cluster for the customer data compared to the general population (e.g. 5% of persons are assigned to a cluster for the general population, but 15% of the customer data is closest to that cluster's centroid) then that suggests the people in that cluster to be a target audience for the company. On the other hand, the proportion of the data in a cluster being larger in the general population than the customer data (e.g. only 2% of customers closest to a population centroid that captures 6% of the data) suggests that group of persons to be outside of the target demographics.
Take a look at the following points in this step:
countplot() or barplot() function could be handy..inverse_transform() method of the PCA and StandardScaler objects to transform centroids back to the original data space and interpret the retrieved values directly.# Compare the proportion of data in each cluster for the customer data to the
# proportion of data in each cluster for the general population.
def compare_gen_cus(general, customer):
fig = plt.figure(figsize=(15, 10))
ax1 = fig.add_subplot(2, 2, 1)
ax2 = fig.add_subplot(2, 2, 2)
ax1.set_title('General Demographics')
ax2.set_title('Customer Demographics')
ax1.set_xlabel('Cluster')
ax2.set_xlabel('Cluster')
sns.countplot(general, ax=ax1)
sns.countplot(customer, ax=ax2)
compare_gen_cus(labels, pred_customer)
pca_financial = ['HH_EINKOMMEN_SCORE', 'CAMEO_INTL_2015_WEALTH', 'FINANZ_VORSORGER', 'FINANZ_SPARER',
'FINANZ_MINIMALIST', 'FINANZ_UNAUFFAELLIGER']
pca_personality = ['SEMIO_ERL', 'SEMIO_LUST', 'SEMIO_REL', 'SEMIO_PFLICHT']
pca_others = ['MOBI_REGIO', 'ALTERSKATEGORIE_GROB', 'KBA05_ANTG1', 'KBA05_GBZ', 'PLZ8_ANTG1',
'PLZ8_ANTG3', 'PRAEGENDE_JUGENDJAHRE_DECADE','ANREDE_KZ']
# Positive Values from the first principal components
# LP_STATUS_GROB (Social status)
# HH_EINKOMMEN_SCORE (Estimated household net income)
# CAMEO_INTL_2015_WEALTH (German Cameo: Wealth typology mapped to international code)
# PLZ8_ANTG3 (Number of cars in 6-10 family houses in the PLZ8 region)
# Positive Values from the second principal components
# ALTERSKATEGORIE_GROB (Estimated age based on given name analysis)
# FINANZ_VORSORGER (financial typology of the be prepared)
# SEMIO_ERL (Personality typology of the event-oriented)
# SEMIO_LUST (Personality typology of the sensual-minded)
# Negative Values from the first principal components
# MOBI_REGIO (Movement patterns)
# KBA05_ANTG1 (Number of 1-2 family houses in the microcell)
# FINANZ_MINIMALIST (Financial typology of the people with low financial interest)
# PLZ8_ANTG1 (Number of cars in 1-2 family houses in the PLZ8 region)
# KBA05_GBZ (Number of buildings in the microcell)
# Negative Values from the second principal components
# PRAEGENDE_JUGENDJAHRE_DECADE (Dominating movement of person's youth by age decade)
# FINANZ_SPARER (Financial typology of money-saver)
# SEMIO_REL (Personality typology of the religious-minded)
# SEMIO_PFLICHT (Personality typology of the dutiful-minded)
# FINANZ_UNAUFFAELLIGER (Financial typology of the inconsipicuous)
count_gen = pd.Series(labels).value_counts(normalize=True)
count_gen = pd.DataFrame({'Ratio of labels in General Population': count_gen})
count_gen
count_cus = pd.Series(pred_customer).value_counts(normalize=True)
count_cus = pd.DataFrame({'Ratio of labels in Customer Population': count_cus})
diff = count_cus['Ratio of labels in Customer Population'] - count_gen['Ratio of labels in General Population']
diff.sort_values(inplace=True)
diff
# What kinds of people are part of a cluster that is overrepresented in the
# customer data compared to the general population?
over_rep = scaler.inverse_transform(pca.inverse_transform(X_pca[labels == 4]))
over_rep = pd.DataFrame(np.round(over_rep), columns=azdias.columns)
# https://stackoverflow.com/questions/49885007/how-to-use-scikit-learn-inverse-transform-with-new-values
def compare_clusters(k, column):
fig= plt.figure(figsize=(15, 10))
ax = fig.add_subplot(4, 2, 4)
sns.countplot(k[column], ax=ax)
for val in pca_financial:
compare_clusters(over_rep, val)
for val in pca_personality:
compare_clusters(over_rep, val)
for val in pca_others:
compare_clusters(over_rep, val)
##What kinds of people are part of a cluster that is underrepresented in the
# customer data compared to the general population?):
under_rep = scaler.inverse_transform(pca.inverse_transform(X_pca[labels == 11]))
under_rep = pd.DataFrame(np.round(under_rep), columns=cleaned_customer.columns)
for val in pca_financial:
compare_clusters(under_rep, val)
for val in pca_personality:
compare_clusters(under_rep, val)
for val in pca_others:
compare_clusters(under_rep, val)
(reporting findings and conclusions from the clustering analysis. Can we describe segments of the population that are relatively popular with the mail-order company, or relatively unpopular with the company?)
From the clustring analysis, I found out that cluster 4 is overrepresented in the customer data (60.78% of the customer population and 11.28% of the general population) compared to the general population while cluster 11 is underrepresented (2.34% of the customer population and 0.43% of the general population).
I will elaborate on this finding below.
Cluster 4: Financial Demographics
Cluster 4: Personality Demographics
Cluster 4: Other Demographics
Cluster 11: Financial Demographics
Cluster 11: Personality Demographics
Cluster 11: Other Demographics
So in conclusion, the population that are relatively popular with the mail-order company are those who over 60s from wealthy households comprised on 1-2 families, live in crowded area(probably cities considering the high number of buildings in microcell), and highly religous and dutiful while unpopular ones are females between 46-60 years old from relatively low income household who live in probably suburbs and have low affinity to religiousness and dutifullness.
This is the end of my analysis. I only looked into columns that are seemed to have high positive or negative weights in the principal components but I'm not so sure this was the right move.
Congratulations on making it this far in the project! Before you finish, make sure to check through the entire notebook from top to bottom to make sure that your analysis follows a logical flow and all of your findings are documented in Discussion cells. Once you've checked over all of your work, you should export the notebook as an HTML document to submit for evaluation. You can do this from the menu, navigating to File -> Download as -> HTML (.html). You will submit both that document and this notebook for your project submission.